iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Build on AWS

30 天將工作室 SaaS 產品部署起來系列 第 24

Day 24: 30天部署SaaS產品到AWS-AWS SES 整合與郵件送達率優化完全指南

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 21-23 的 CI/CD 與部署策略建立,我們已經有了完整的自動化部署體系。今天我們開始其他實作:AWS SES (Simple Email Service) 整合。作為專業的郵件發送服務,SES 提供高送達率、低成本、以及強大的監控能力。我們將實作 SES 的設定、域名驗證、DKIM/SPF 配置、以及送達率優化策略。

AWS SES 核心概念

/**
 * AWS SES 核心概念
 *
 * 1. 沙盒模式 (Sandbox Mode)
 *    - 新帳號預設在沙盒模式
 *    - 限制:只能發送給已驗證的信箱
 *    - 限制:每日 200 封,每秒 1 封
 *    - 需申請移出沙盒才能用於生產
 *
 * 2. 身份驗證
 *    - Email Address:驗證單一信箱
 *    - Domain:驗證整個域名(推薦)
 *    - 需在 DNS 新增驗證記錄
 *
 * 3. 發送配額
 *    - 每日發送配額:從 200 開始
 *    - 發送速率:從 1 封/秒開始
 *    - 會根據信譽自動調整
 *
 * 4. 信譽管理
 *    - Bounce Rate(退信率)< 5%
 *    - Complaint Rate(投訴率)< 0.1%
 *    - 違反會導致停權
 *
 * 5. 定價 (ap-northeast-1 Tokyo)
 *    - 前 62,000 封/月:免費(AWS Free Tier)
 *    - 之後:$0.10 / 1,000 封
 *    - 附件:$0.12 / GB
 *    - Dedicated IP:$24.95/月
 */

SES 環境設定

1. IAM 權限設定

// infrastructure/lib/ses-iam-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

export class SESIAMStack extends cdk.Stack {
  public readonly sesUser: iam.User;
  public readonly sesAccessKey: iam.AccessKey;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 建立 SES 專用 IAM User
    this.sesUser = new iam.User(this, 'SESUser', {
      userName: 'kyo-ses-user',
    });

    // 建立 SES 發送政策
    const sesPolicy = new iam.Policy(this, 'SESPolicy', {
      policyName: 'KyoSESPolicy',
      statements: [
        // 發送郵件權限
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'ses:SendEmail',
            'ses:SendRawEmail',
            'ses:SendTemplatedEmail',
            'ses:SendBulkTemplatedEmail',
          ],
          resources: ['*'],
        }),

        // 取得發送配額
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'ses:GetSendQuota',
            'ses:GetSendStatistics',
            'ses:GetAccount',
          ],
          resources: ['*'],
        }),

        // 管理模板
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'ses:CreateTemplate',
            'ses:UpdateTemplate',
            'ses:GetTemplate',
            'ses:ListTemplates',
            'ses:DeleteTemplate',
          ],
          resources: ['*'],
        }),

        // 管理身份驗證
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'ses:VerifyEmailIdentity',
            'ses:VerifyDomainIdentity',
            'ses:GetIdentityVerificationAttributes',
            'ses:DeleteIdentity',
          ],
          resources: ['*'],
        }),

        // 管理組態集 (Configuration Sets)
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'ses:CreateConfigurationSet',
            'ses:UpdateConfigurationSet',
            'ses:DeleteConfigurationSet',
            'ses:DescribeConfigurationSet',
            'ses:CreateConfigurationSetEventDestination',
          ],
          resources: ['*'],
        }),
      ],
    });

    sesPolicy.attachToUser(this.sesUser);

    // 建立 Access Key
    this.sesAccessKey = new iam.AccessKey(this, 'SESAccessKey', {
      user: this.sesUser,
    });

    // 輸出憑證(僅用於開發,生產環境應使用 Secrets Manager)
    new cdk.CfnOutput(this, 'AccessKeyId', {
      value: this.sesAccessKey.accessKeyId,
      description: 'SES Access Key ID',
    });

    new cdk.CfnOutput(this, 'SecretAccessKey', {
      value: this.sesAccessKey.secretAccessKey.unsafeUnwrap(),
      description: 'SES Secret Access Key (Store securely!)',
    });
  }
}

2. 域名驗證設定

// infrastructure/lib/ses-domain-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ses from 'aws-cdk-lib/aws-ses';
import * as route53 from 'aws-cdk-lib/aws-route53';
import { Construct } from 'constructs';

export class SESDomainStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const domain = 'kyong.com';

    // 取得 Route53 Hosted Zone
    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: domain,
    });

    // 建立 SES 域名身份
    const emailIdentity = new ses.EmailIdentity(this, 'EmailIdentity', {
      identity: ses.Identity.publicHostedZone(hostedZone),
      // 啟用 DKIM 簽章
      dkimSigning: ses.DkimSigning.enabled(),
      // 設定郵件來源
      mailFromDomain: `mail.${domain}`,
      // 設定 MX 失敗時的行為
      behaviorOnMxFailure: ses.BehaviorOnMxFailure.REJECT_MESSAGE,
    });

    // 輸出 DKIM 記錄(如果需要手動設定)
    new cdk.CfnOutput(this, 'DomainIdentityArn', {
      value: emailIdentity.emailIdentityArn,
      description: 'SES Domain Identity ARN',
    });

    // 建立組態集用於追蹤
    const configurationSet = new ses.ConfigurationSet(this, 'ConfigSet', {
      configurationSetName: 'kyo-production',
      // 啟用聲譽指標
      reputationMetrics: true,
      // 發送功能
      sendingEnabled: true,
      // 自訂重定向域名
      suppressionReasons: ses.SuppressionReasons.BOUNCES_AND_COMPLAINTS,
    });

    // SNS Topic 用於接收事件
    const snsTopic = new sns.Topic(this, 'SESEventsTopic', {
      topicName: 'ses-events',
      displayName: 'SES Events Topic',
    });

    // 建立事件目的地
    configurationSet.addEventDestination('SNSEventDestination', {
      destination: ses.EventDestination.snsTopic(snsTopic),
      events: [
        ses.EmailSendingEvent.SEND,
        ses.EmailSendingEvent.DELIVERY,
        ses.EmailSendingEvent.BOUNCE,
        ses.EmailSendingEvent.COMPLAINT,
        ses.EmailSendingEvent.REJECT,
        ses.EmailSendingEvent.OPEN,
        ses.EmailSendingEvent.CLICK,
      ],
      enabled: true,
    });

    // 輸出組態集名稱
    new cdk.CfnOutput(this, 'ConfigurationSetName', {
      value: configurationSet.configurationSetName,
      description: 'SES Configuration Set Name',
    });

    new cdk.CfnOutput(this, 'SNSTopicArn', {
      value: snsTopic.topicArn,
      description: 'SNS Topic ARN for SES events',
    });
  }
}

DNS 記錄設定指南

#!/bin/bash
# scripts/setup-ses-dns.sh

# 這個腳本會輸出需要在 DNS 設定的記錄

DOMAIN="kyong.com"
REGION="ap-northeast-1"

echo "=== SES DNS 設定指南 for ${DOMAIN} ==="
echo ""

# 1. 域名驗證記錄
echo "1. 域名驗證 TXT 記錄"
echo "   類型: TXT"
echo "   名稱: _amazonses.${DOMAIN}"
echo "   值: [從 SES Console 取得]"
echo ""

# 2. DKIM 記錄
echo "2. DKIM CNAME 記錄 (3 筆)"
echo "   SES 會提供 3 個 DKIM token,需分別建立 CNAME:"
echo ""
echo "   類型: CNAME"
echo "   名稱: <token1>._domainkey.${DOMAIN}"
echo "   值: <token1>.dkim.amazonses.com"
echo ""
echo "   類型: CNAME"
echo "   名稱: <token2>._domainkey.${DOMAIN}"
echo "   值: <token2>.dkim.amazonses.com"
echo ""
echo "   類型: CNAME"
echo "   名稱: <token3>._domainkey.${DOMAIN}"
echo "   值: <token3>.dkim.amazonses.com"
echo ""

# 3. SPF 記錄
echo "3. SPF TXT 記錄"
echo "   類型: TXT"
echo "   名稱: ${DOMAIN}"
echo "   值: \"v=spf1 include:amazonses.com ~all\""
echo ""

# 4. DMARC 記錄
echo "4. DMARC TXT 記錄 (建議)"
echo "   類型: TXT"
echo "   名稱: _dmarc.${DOMAIN}"
echo "   值: \"v=DMARC1; p=quarantine; rua=mailto:dmarc@${DOMAIN}\""
echo ""

# 5. 自訂 MAIL FROM 域名
echo "5. 自訂 MAIL FROM (MX 和 SPF)"
echo "   MX 記錄:"
echo "   類型: MX"
echo "   名稱: mail.${DOMAIN}"
echo "   值: 10 feedback-smtp.${REGION}.amazonses.com"
echo ""
echo "   SPF 記錄:"
echo "   類型: TXT"
echo "   名稱: mail.${DOMAIN}"
echo "   值: \"v=spf1 include:amazonses.com ~all\""
echo ""

# 驗證指令
echo "=== 驗證 DNS 設定 ==="
echo ""
echo "# 驗證域名驗證記錄"
echo "dig TXT _amazonses.${DOMAIN} +short"
echo ""
echo "# 驗證 SPF"
echo "dig TXT ${DOMAIN} +short | grep spf"
echo ""
echo "# 驗證 DKIM"
echo "dig CNAME <token1>._domainkey.${DOMAIN} +short"
echo ""
echo "# 驗證 DMARC"
echo "dig TXT _dmarc.${DOMAIN} +short"
echo ""
echo "# 驗證 MX"
echo "dig MX mail.${DOMAIN} +short"

移出沙盒申請

// scripts/request-production-access.ts
/**
 * AWS SES 移出沙盒申請指南
 *
 * 步驟 1: 前往 AWS Support Center
 * https://console.aws.amazon.com/support/home#/case/create
 *
 * 步驟 2: 建立案例
 * - Service: SES Sending Limits Increase
 * - Category: Service Limit Increase
 *
 * 步驟 3: 填寫申請資訊
 */

interface ProductionAccessRequest {
  // 1. 基本資訊
  region: string; // 例如: ap-northeast-1
  mailType: 'Transactional' | 'Marketing' | 'Mixed';
  website: string;

  // 2. 用途說明
  useCase: string;
  // 範例:
  // "We provide an OTP (One-Time Password) service for enterprise customers.
  //  Our service sends transactional emails including:
  //  - OTP verification codes
  //  - Account activation emails
  //  - Password reset notifications
  //  We use a double opt-in process and maintain strict bounce/complaint management."

  // 3. 退信處理流程
  bounceHandling: string;
  // 範例:
  // "We monitor bounce notifications via SNS and:
  //  1. Hard bounces: Immediately remove from list
  //  2. Soft bounces: Retry up to 3 times, then remove
  //  3. We maintain bounce rate < 5%"

  // 4. 投訴處理流程
  complaintHandling: string;
  // 範例:
  // "We monitor complaint notifications via SNS and:
  //  1. Immediately unsubscribe complainants
  //  2. Investigate root cause
  //  3. We maintain complaint rate < 0.1%"

  // 5. 郵件列表來源
  listAcquisition: string;
  // 範例:
  // "All email addresses are:
  //  - Directly provided by users during registration
  //  - Verified via double opt-in
  //  - Never purchased or scraped"

  // 6. 請求配額
  dailyQuota: number; // 例如: 50000
  sendingRate: number; // 例如: 14 (每秒)

  // 7. 合規聲明
  compliance: {
    canSpamAct: boolean; // 美國 CAN-SPAM Act
    gdpr: boolean; // 歐盟 GDPR
    optOutMechanism: boolean; // 有退訂機制
  };
}

const exampleRequest: ProductionAccessRequest = {
  region: 'ap-northeast-1',
  mailType: 'Transactional',
  website: 'https://kyong.com',

  useCase: `
We are building an enterprise OTP (One-Time Password) service platform.

Our service sends transactional emails to verified users, including:
- OTP verification codes for 2FA
- Account activation confirmations
- Password reset links
- Service notifications

We implement best practices:
- Double opt-in verification
- Automated bounce/complaint monitoring via SNS
- Strict list hygiene (bounce < 5%, complaint < 0.1%)
- Unsubscribe links in all emails
- SPF, DKIM, and DMARC authentication

We expect to send approximately 30,000 transactional emails per day initially,
growing to 50,000 within 3 months as we onboard more enterprise customers.
  `.trim(),

  bounceHandling: `
We have implemented automated bounce handling:

1. SNS topic subscribed to bounce notifications
2. Hard bounces: Immediately flagged and excluded from future sends
3. Soft bounces: Retry with exponential backoff (max 3 attempts)
4. Automated alerts when bounce rate exceeds 3%
5. Weekly bounce report review
6. Target bounce rate: < 2%
  `.trim(),

  complaintHandling: `
We take complaints very seriously:

1. SNS topic subscribed to complaint notifications
2. Immediate automatic unsubscribe upon complaint
3. Root cause investigation within 24 hours
4. Sender training if complaint is due to sending practices
5. Monthly complaint rate review
6. Target complaint rate: < 0.05%
  `.trim(),

  listAcquisition: `
All recipient email addresses are obtained through:

1. Direct user registration on our platform
2. Double opt-in verification process
3. Explicit consent for transactional emails
4. We NEVER purchase, rent, or scrape email lists
5. Users can manage preferences and opt-out anytime
6. Regular list cleaning to remove inactive addresses
  `.trim(),

  dailyQuota: 50000,
  sendingRate: 14,

  compliance: {
    canSpamAct: true,
    gdpr: true,
    optOutMechanism: true,
  },
};

// 輸出申請文本
console.log('=== AWS SES Production Access Request ===\n');
console.log('Region:', exampleRequest.region);
console.log('Mail Type:', exampleRequest.mailType);
console.log('Website:', exampleRequest.website);
console.log('\n=== Use Case ===');
console.log(exampleRequest.useCase);
console.log('\n=== Bounce Handling ===');
console.log(exampleRequest.bounceHandling);
console.log('\n=== Complaint Handling ===');
console.log(exampleRequest.complaintHandling);
console.log('\n=== List Acquisition ===');
console.log(exampleRequest.listAcquisition);
console.log('\n=== Requested Limits ===');
console.log(`Daily Quota: ${exampleRequest.dailyQuota} emails/day`);
console.log(`Sending Rate: ${exampleRequest.sendingRate} emails/second`);

送達率優化策略

/**
 * 郵件送達率優化完全指南
 *
 * 1. 技術層面 (Technical)
 *    ✅ SPF 記錄設定
 *    ✅ DKIM 簽章啟用
 *    ✅ DMARC 政策設定
 *    ✅ 自訂 MAIL FROM 域名
 *    ✅ 使用 Dedicated IP (大量發送)
 *
 * 2. 內容層面 (Content)
 *    ✅ 避免垃圾郵件觸發詞
 *       ❌ "FREE", "!!!", "CLICK HERE NOW"
 *       ✅ 專業、清晰、有價值的內容
 *    ✅ 平衡文字與圖片比例 (60:40)
 *    ✅ 提供純文字版本
 *    ✅ 清晰的退訂連結
 *    ✅ 實體地址 (法規要求)
 *
 * 3. 列表管理 (List Hygiene)
 *    ✅ Double Opt-in 驗證
 *    ✅ 定期清理無效地址
 *    ✅ 移除退信地址 (Hard Bounce)
 *    ✅ 尊重投訴與退訂
 *    ✅ 區隔活躍/不活躍用戶
 *
 * 4. 發送策略 (Sending Strategy)
 *    ✅ IP 預熱 (Warming)
 *    ✅ 避免流量突增
 *    ✅ 一致的發送模式
 *    ✅ 監控關鍵指標
 *
 * 5. 監控指標 (Metrics)
 *    📊 Open Rate: 15-25% (正常)
 *    📊 Click Rate: 2-5% (正常)
 *    📊 Bounce Rate: < 2% (健康)
 *    📊 Complaint Rate: < 0.1% (健康)
 *    📊 Unsubscribe Rate: < 0.5% (健康)
 */

interface EmailHealthMetrics {
  bounceRate: number; // 退信率
  complaintRate: number; // 投訴率
  openRate: number; // 開信率
  clickRate: number; // 點擊率
  unsubscribeRate: number; // 退訂率
}

function evaluateEmailHealth(metrics: EmailHealthMetrics): {
  status: 'healthy' | 'warning' | 'critical';
  issues: string[];
  recommendations: string[];
} {
  const issues: string[] = [];
  const recommendations: string[] = [];

  // 檢查退信率
  if (metrics.bounceRate > 5) {
    issues.push(`High bounce rate: ${metrics.bounceRate.toFixed(2)}%`);
    recommendations.push('Implement stricter email validation');
    recommendations.push('Remove hard bounces immediately');
  } else if (metrics.bounceRate > 2) {
    issues.push(`Elevated bounce rate: ${metrics.bounceRate.toFixed(2)}%`);
    recommendations.push('Review email list quality');
  }

  // 檢查投訴率
  if (metrics.complaintRate > 0.1) {
    issues.push(`High complaint rate: ${metrics.complaintRate.toFixed(3)}%`);
    recommendations.push('Review email content and frequency');
    recommendations.push('Ensure clear unsubscribe option');
  }

  // 檢查開信率
  if (metrics.openRate < 10) {
    issues.push(`Low open rate: ${metrics.openRate.toFixed(2)}%`);
    recommendations.push('Improve subject lines');
    recommendations.push('Optimize send time');
    recommendations.push('Review sender reputation');
  }

  // 判斷整體健康狀態
  let status: 'healthy' | 'warning' | 'critical';

  if (metrics.bounceRate > 5 || metrics.complaintRate > 0.1) {
    status = 'critical';
  } else if (
    metrics.bounceRate > 2 ||
    metrics.complaintRate > 0.05 ||
    metrics.openRate < 10
  ) {
    status = 'warning';
  } else {
    status = 'healthy';
  }

  return { status, issues, recommendations };
}

// 使用範例
const currentMetrics: EmailHealthMetrics = {
  bounceRate: 1.5,
  complaintRate: 0.03,
  openRate: 22.5,
  clickRate: 3.2,
  unsubscribeRate: 0.2,
};

const health = evaluateEmailHealth(currentMetrics);

console.log('Email Health Status:', health.status);
if (health.issues.length > 0) {
  console.log('Issues:', health.issues);
  console.log('Recommendations:', health.recommendations);
}

IP 預熱計劃

// scripts/ip-warming-plan.ts
/**
 * Dedicated IP 預熱計劃
 *
 * 為什麼需要預熱?
 * - 新 IP 沒有發送歷史
 * - ISP 會觀察發送模式
 * - 突然大量發送會被視為垃圾郵件
 *
 * 預熱目標:
 * - 建立良好的發送信譽
 * - 逐步增加發送量
 * - 保持低退信/投訴率
 */

interface WarmupSchedule {
  day: number;
  dailyVolume: number;
  notes: string;
}

const dedicatedIPWarmupPlan: WarmupSchedule[] = [
  { day: 1, dailyVolume: 50, notes: '第一天,發送給最活躍用戶' },
  { day: 2, dailyVolume: 100, notes: '雙倍發送量' },
  { day: 3, dailyVolume: 200, notes: '持續增加' },
  { day: 4, dailyVolume: 400, notes: '保持穩定增長' },
  { day: 5, dailyVolume: 800, notes: '持續增長' },
  { day: 6, dailyVolume: 1600, notes: '一週後達到 1600' },
  { day: 7, dailyVolume: 3200, notes: '繼續增長' },
  { day: 8, dailyVolume: 6000, notes: '進入第二週' },
  { day: 9, dailyVolume: 10000, notes: '達到五位數' },
  { day: 10, dailyVolume: 15000, notes: '穩定增長' },
  { day: 11, dailyVolume: 20000, notes: '接近目標' },
  { day: 12, dailyVolume: 25000, notes: '繼續增長' },
  { day: 13, dailyVolume: 30000, notes: '接近預期量' },
  { day: 14, dailyVolume: 40000, notes: '兩週完成' },
  { day: 15, dailyVolume: 50000, notes: '達到預期每日發送量' },
];

/**
 * 預熱最佳實踐
 */
const warmupBestPractices = [
  '✅ 從最活躍、最近互動的用戶開始',
  '✅ 每天增加 50-100%,不要突然跳躍',
  '✅ 保持一致的發送時間',
  '✅ 密切監控退信和投訴率',
  '✅ 如果指標異常,放慢增長速度',
  '✅ 使用高品質內容,確保高開信率',
  '✅ 預熱期間避免促銷郵件',
  '❌ 不要在預熱期間暫停',
  '❌ 不要突然大量增加發送',
  '❌ 不要忽略負面指標',
];

// 輸出預熱計劃
console.log('=== Dedicated IP Warming Plan ===\n');
console.log('Day | Daily Volume | Cumulative | Notes');
console.log('----|--------------|------------|------');

let cumulative = 0;
dedicatedIPWarmupPlan.forEach((schedule) => {
  cumulative += schedule.dailyVolume;
  console.log(
    `${schedule.day.toString().padStart(3)} | ${schedule.dailyVolume
      .toString()
      .padStart(12)} | ${cumulative.toString().padStart(10)} | ${schedule.notes}`
  );
});

console.log('\n=== Best Practices ===');
warmupBestPractices.forEach((practice) => console.log(practice));

SES 事件處理

// apps/kyo-otp-service/src/webhooks/ses-events.ts
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';

const sesEventSchema = z.object({
  Type: z.string(),
  MessageId: z.string(),
  Message: z.string(),
  Timestamp: z.string(),
  Signature: z.string(),
  SigningCertURL: z.string(),
  UnsubscribeURL: z.string(),
});

const sesBounceSchema = z.object({
  eventType: z.literal('Bounce'),
  bounce: z.object({
    bounceType: z.enum(['Undetermined', 'Permanent', 'Transient']),
    bounceSubType: z.string(),
    bouncedRecipients: z.array(
      z.object({
        emailAddress: z.string(),
        action: z.string().optional(),
        status: z.string().optional(),
        diagnosticCode: z.string().optional(),
      })
    ),
    timestamp: z.string(),
    feedbackId: z.string(),
  }),
  mail: z.object({
    messageId: z.string(),
    timestamp: z.string(),
    source: z.string(),
    destination: z.array(z.string()),
  }),
});

const sesComplaintSchema = z.object({
  eventType: z.literal('Complaint'),
  complaint: z.object({
    complainedRecipients: z.array(
      z.object({
        emailAddress: z.string(),
      })
    ),
    timestamp: z.string(),
    feedbackId: z.string(),
    complaintFeedbackType: z.string().optional(),
  }),
  mail: z.object({
    messageId: z.string(),
    timestamp: z.string(),
    source: z.string(),
  }),
});

const sesDeliverySchema = z.object({
  eventType: z.literal('Delivery'),
  delivery: z.object({
    timestamp: z.string(),
    processingTimeMillis: z.number(),
    recipients: z.array(z.string()),
    smtpResponse: z.string(),
  }),
  mail: z.object({
    messageId: z.string(),
  }),
});

export const sesEventsRoutes: FastifyPluginAsync = async (server) => {
  /**
   * SNS 訂閱確認
   */
  server.post('/ses/events', async (request, reply) => {
    const body = sesEventSchema.parse(request.body);

    // 處理訂閱確認
    if (body.Type === 'SubscriptionConfirmation') {
      const confirmUrl = body.UnsubscribeURL;
      await fetch(confirmUrl);

      server.log.info('SNS subscription confirmed');
      return reply.code(200).send({ message: 'Subscription confirmed' });
    }

    // 處理通知
    if (body.Type === 'Notification') {
      const message = JSON.parse(body.Message);

      switch (message.eventType) {
        case 'Bounce':
          await handleBounce(sesBounceSchema.parse(message));
          break;

        case 'Complaint':
          await handleComplaint(sesComplaintSchema.parse(message));
          break;

        case 'Delivery':
          await handleDelivery(sesDeliverySchema.parse(message));
          break;

        case 'Open':
          await handleOpen(message);
          break;

        case 'Click':
          await handleClick(message);
          break;

        default:
          server.log.warn(`Unknown SES event type: ${message.eventType}`);
      }

      return reply.code(200).send({ message: 'Event processed' });
    }

    return reply.code(400).send({ error: 'Unknown message type' });
  });

  /**
   * 處理退信
   */
  async function handleBounce(event: z.infer<typeof sesBounceSchema>) {
    server.log.info('Processing bounce event', {
      messageId: event.mail.messageId,
      bounceType: event.bounce.bounceType,
    });

    for (const recipient of event.bounce.bouncedRecipients) {
      const email = recipient.emailAddress;

      if (event.bounce.bounceType === 'Permanent') {
        // 永久退信:立即從列表移除
        await markEmailAsInvalid(email, 'hard_bounce');
        server.log.warn(`Hard bounce: ${email} - removed from list`);
      } else if (event.bounce.bounceType === 'Transient') {
        // 暫時性退信:標記並限制重試
        await incrementBounceCount(email);
        server.log.info(`Soft bounce: ${email} - retry limited`);
      }

      // 記錄事件
      await logEmailEvent({
        type: 'bounce',
        email,
        messageId: event.mail.messageId,
        metadata: {
          bounceType: event.bounce.bounceType,
          bounceSubType: event.bounce.bounceSubType,
          diagnosticCode: recipient.diagnosticCode,
        },
        timestamp: new Date(event.bounce.timestamp),
      });
    }
  }

  /**
   * 處理投訴
   */
  async function handleComplaint(event: z.infer<typeof sesComplaintSchema>) {
    server.log.warn('Processing complaint event', {
      messageId: event.mail.messageId,
    });

    for (const recipient of event.complaint.complainedRecipients) {
      const email = recipient.emailAddress;

      // 立即退訂
      await unsubscribeEmail(email, 'complaint');

      // 標記為投訴
      await markEmailAsComplaint(email);

      server.log.error(`Complaint received: ${email} - unsubscribed`);

      // 記錄事件
      await logEmailEvent({
        type: 'complaint',
        email,
        messageId: event.mail.messageId,
        metadata: {
          feedbackType: event.complaint.complaintFeedbackType,
        },
        timestamp: new Date(event.complaint.timestamp),
      });

      // 發送告警
      await sendAlert({
        type: 'complaint',
        email,
        messageId: event.mail.messageId,
      });
    }
  }

  /**
   * 處理成功送達
   */
  async function handleDelivery(event: z.infer<typeof sesDeliverySchema>) {
    for (const email of event.delivery.recipients) {
      await logEmailEvent({
        type: 'delivered',
        email,
        messageId: event.mail.messageId,
        metadata: {
          processingTime: event.delivery.processingTimeMillis,
          smtpResponse: event.delivery.smtpResponse,
        },
        timestamp: new Date(event.delivery.timestamp),
      });
    }
  }

  /**
   * 處理開信
   */
  async function handleOpen(event: any) {
    await logEmailEvent({
      type: 'open',
      email: event.mail.destination[0],
      messageId: event.mail.messageId,
      metadata: {
        userAgent: event.open.userAgent,
        ipAddress: event.open.ipAddress,
      },
      timestamp: new Date(event.open.timestamp),
    });
  }

  /**
   * 處理點擊
   */
  async function handleClick(event: any) {
    await logEmailEvent({
      type: 'click',
      email: event.mail.destination[0],
      messageId: event.mail.messageId,
      metadata: {
        link: event.click.link,
        userAgent: event.click.userAgent,
      },
      timestamp: new Date(event.click.timestamp),
    });
  }

  // 輔助函數(實際應連接資料庫)
  async function markEmailAsInvalid(email: string, reason: string) {
    // TODO: Update database
    server.log.info(`Marking email as invalid: ${email} (${reason})`);
  }

  async function incrementBounceCount(email: string) {
    // TODO: Update database
    server.log.info(`Incrementing bounce count: ${email}`);
  }

  async function unsubscribeEmail(email: string, reason: string) {
    // TODO: Update database
    server.log.info(`Unsubscribing email: ${email} (${reason})`);
  }

  async function markEmailAsComplaint(email: string) {
    // TODO: Update database
    server.log.warn(`Marking email as complaint: ${email}`);
  }

  async function logEmailEvent(event: any) {
    // TODO: Store in database
    server.log.info('Email event logged', event);
  }

  async function sendAlert(alert: any) {
    // TODO: Send to monitoring system
    server.log.error('Alert triggered', alert);
  }
};

成本分析與優化

/**
 * AWS SES 成本分析 (ap-northeast-1 Tokyo)
 *
 * 基礎定價:
 * - 前 62,000 封/月:$0(AWS Free Tier)
 * - 之後:$0.10 / 1,000 封
 * - 附件:$0.12 / GB
 * - Dedicated IP:$24.95 / 月
 * - 接收郵件:$0.10 / 1,000 封
 *
 * 成本範例:
 */

interface SESCostEstimate {
  monthlyVolume: number;
  attachmentsGB?: number;
  dedicatedIP?: boolean;
}

function calculateSESCost(config: SESCostEstimate): {
  emailCost: number;
  attachmentCost: number;
  dedicatedIPCost: number;
  totalMonthlyCost: number;
} {
  const FREE_TIER = 62000;
  const COST_PER_1000 = 0.1;
  const ATTACHMENT_COST_PER_GB = 0.12;
  const DEDICATED_IP_COST = 24.95;

  // 郵件發送成本
  const billableEmails = Math.max(0, config.monthlyVolume - FREE_TIER);
  const emailCost = (billableEmails / 1000) * COST_PER_1000;

  // 附件成本
  const attachmentCost = (config.attachmentsGB || 0) * ATTACHMENT_COST_PER_GB;

  // Dedicated IP 成本
  const dedicatedIPCost = config.dedicatedIP ? DEDICATED_IP_COST : 0;

  const totalMonthlyCost = emailCost + attachmentCost + dedicatedIPCost;

  return {
    emailCost,
    attachmentCost,
    dedicatedIPCost,
    totalMonthlyCost,
  };
}

// 範例 1: 小型應用(免費額度內)
const smallApp = calculateSESCost({
  monthlyVolume: 50000,
  dedicatedIP: false,
});

console.log('=== Small App (50K emails/month) ===');
console.log('Email Cost: $' + smallApp.emailCost.toFixed(2));
console.log('Total: $' + smallApp.totalMonthlyCost.toFixed(2));
console.log('');

// 範例 2: 中型應用
const mediumApp = calculateSESCost({
  monthlyVolume: 500000,
  attachmentsGB: 5,
  dedicatedIP: false,
});

console.log('=== Medium App (500K emails/month) ===');
console.log('Email Cost: $' + mediumApp.emailCost.toFixed(2));
console.log('Attachment Cost: $' + mediumApp.attachmentCost.toFixed(2));
console.log('Total: $' + mediumApp.totalMonthlyCost.toFixed(2));
console.log('');

// 範例 3: 大型應用(使用 Dedicated IP)
const largeApp = calculateSESCost({
  monthlyVolume: 2000000,
  attachmentsGB: 20,
  dedicatedIP: true,
});

console.log('=== Large App (2M emails/month with Dedicated IP) ===');
console.log('Email Cost: $' + largeApp.emailCost.toFixed(2));
console.log('Attachment Cost: $' + largeApp.attachmentCost.toFixed(2));
console.log('Dedicated IP Cost: $' + largeApp.dedicatedIPCost.toFixed(2));
console.log('Total: $' + largeApp.totalMonthlyCost.toFixed(2));
console.log('');

/**
 * 成本優化策略:
 *
 * 1. 利用 Free Tier
 *    - 前 62,000 封免費
 *    - 多帳號策略(不推薦,違反 ToS)
 *
 * 2. 批次發送
 *    - 減少 API 調用次數
 *    - 降低網路開銷
 *
 * 3. 移除無效地址
 *    - 減少浪費
 *    - 提升送達率
 *
 * 4. 附件優化
 *    - 使用外部連結代替附件
 *    - 壓縮附件檔案
 *
 * 5. Dedicated IP 評估
 *    - 僅在發送量 > 100K/day 時考慮
 *    - 需要 IP 預熱
 *    - 每月額外 $24.95
 */

今日總結

我們今天完成了 AWS SES 的完整整合:

核心功能

  1. 身份驗證: Domain 驗證 + DKIM + SPF + DMARC
  2. 生產環境: 移出沙盒申請指南
  3. 送達率優化: IP 預熱 + 列表管理 + 內容優化
  4. 事件處理: SNS Webhook + 退信/投訴管理
  5. 成本優化: Free Tier 利用 + 批次發送

技術分析

Shared IP vs Dedicated IP:

  • Shared IP: 免費、適合中小量、與他人共享信譽
  • Dedicated IP: $24.95/月、獨立信譽、需預熱
  • 💡 建議:< 100K/day 用 Shared IP

退信類型處理:

  • Hard Bounce: 永久錯誤(地址不存在)→ 立即移除
  • Soft Bounce: 暫時錯誤(信箱滿)→ 重試 3 次
  • Block Bounce: 被 ISP 阻擋 → 檢查信譽

DKIM vs SPF vs DMARC:

  • SPF: 授權發送伺服器(IP 白名單)
  • DKIM: 郵件內容簽章驗證
  • DMARC: 整合 SPF + DKIM 的政策
  • 💡 建議:三者都要設定

SES 檢查清單

  • ✅ 域名驗證完成
  • ✅ DKIM 簽章啟用
  • ✅ SPF 記錄設定
  • ✅ DMARC 政策設定
  • ✅ 自訂 MAIL FROM
  • ✅ Configuration Set 建立
  • ✅ SNS 事件通知
  • ✅ 退信/投訴處理
  • ✅ 移出沙盒申請
  • ✅ 監控告警設定

上一篇
Day 23: 30天部署SaaS產品到AWS-生產環境部署策略與金絲雀發布
下一篇
Day 25: 30天部署SaaS產品到AWS-AWS Cognito 整合與用戶池管理
系列文
30 天將工作室 SaaS 產品部署起來25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言